Skip to content

fix: scope circuit device ids to the panel that owns them - #22

Merged
cayossarian merged 3 commits into
mainfrom
fix/panel-scoped-circuit-ids
Aug 29, 2026
Merged

fix: scope circuit device ids to the panel that owns them#22
cayossarian merged 3 commits into
mainfrom
fix/panel-scoped-circuit-ids

Conversation

@cayossarian

@cayossarian cayossarian commented Aug 29, 2026

Copy link
Copy Markdown
Member

The collision

stable_circuit_uuid derived a circuit's Homie device id from the YAML circuit id alone. Those ids are shared vocabulary across the shipped configs — default_MAIN_32.yaml and default_MAIN_40.yaml both define a solar_inverter and an oven. A real panel has its own broker, so it cannot collide; two simulated panels in one add-on share one, and the two solar_inverters then claim the same device id and the same ebus/5/<uuid>/... topics.

Both panels write every tick, so a consumer reads each circuit's meters as the two unrelated loads alternating. Observed on ha-test with sim-32t-001 and sim-40t-001: 16 energy counters flipping between the two panels' values every tick (space 12 exported-energy 53.18 → 37.44 → 53.27 → 37.52), with Home Assistant's dip compensation firing continuously.

The change

stable_circuit_uuid(panel_id, circuit_id) hashes <panel-serial>/<circuit-id>, scoping a circuit the way every other id in instance_ids is already scoped to its proxier. The namespace is unchanged. The separator is / and not - because a panel serial may itself contain -, and any separator the serial can contain admits two different (panel, circuit) pairs hashing the same name.

Every caller now threads the panel serial from panel_config.serial_number — the one key, never a second one alongside it. _feeds_for_device_type reads it from the config it is already walking rather than taking it as a parameter, so a feed id cannot be built from a serial that does not own those circuits.

Cross-repo contract

The flat simulator publishes the same circuit ids for the same config, and the firmware-upgrade rehearsal stops one and starts the other on a single panel — a circuit whose id changed at the swap strands its Home Assistant history. The identical change lands in SpanPanel/simulator#50.

tests/test_instance_ids_contract.py pins the derivation as values rather than as a formula:

stable_circuit_uuid("sim-40t-001", "solar_inverter") == "be87c32bda4f5cd9abbf6d3995ae28c0"
stable_circuit_uuid("sim-40t-002", "solar_inverter") == "946438eed82a57f3a1d7cbeea37bbb29"

The simulator's suite asserts the same two, so a change made on one side alone fails CI on both. Two rather than one because a single value cannot distinguish the panel scoping from any other change that happens to agree on one input; the pair differs only in the serial, which pins the serial as an input rather than as a constant folded into the namespace. A third test states the bug itself: two panels sharing a circuit id must not share a device id.

Fixtures

golden_wire.json was recaptured with scripts/capture-wire.py. golden_tree.json had its circuit ids rewritten in place, and golden_report.json was regenerated from it per DEVELOPER.md — the regenerated report is the old one's observations unchanged as a multiset, reordered only because the report sorts by device id.

Upgrade note

Released as 2.5.3. A simulated panel already added to Home Assistant has to be removed and re-added: its circuits get new device ids, so its circuit entities get new unique ids.

Full suite (394 tests) and pre-commit run --all-files are green.

`stable_circuit_uuid` hashed the YAML circuit `id` alone, and those ids are
shared vocabulary across the shipped configs — `default_MAIN_32.yaml` and
`default_MAIN_40.yaml` both define a `solar_inverter` and an `oven`. One panel
per broker makes that harmless, which is why it survived. Two panels in one
add-on share a broker: the two `solar_inverter`s claim the same Homie device id
and the same `ebus/5/<uuid>/...` topics, both write every tick, and a consumer
reads each circuit's meters as the two unrelated loads alternating. Home
Assistant's dip compensation then fires continuously on counters that fall as
often as they rise.

The name hashed is now `<panel-serial>/<circuit-id>`, which scopes a circuit the
way every other id here is already scoped to its proxier. The separator is `/`
because a panel serial may contain `-`, and any separator the serial can contain
admits two different (panel, circuit) pairs hashing the same name.

The derivation is shared byte-for-byte with the flat simulator, which publishes
the same circuit ids for the same config — the firmware-upgrade rehearsal stops
one and starts the other on one panel. `tests/test_instance_ids_contract.py`
pins the value rather than the formula so a one-sided change fails CI.

Real firmware cannot hit the collision, since a panel has its own broker.
Circuit device ids are now panel-scoped, so a simulated panel already added to
Home Assistant has to be removed and re-added.
One pinned value cannot distinguish the panel scoping from any other change that
happens to agree on a single input. `sim-40t-002` differs from `sim-40t-001` only
in the serial, so the pair pins the serial as an input rather than as a constant
folded into the namespace. Both literals are the ones the simulator's suite
asserts.
@cayossarian
cayossarian merged commit 51af6fc into main Aug 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant